fix(keystore): key iOS keystore salt by a stable id, not the DB path - #2526
fix(keystore): key iOS keystore salt by a stable id, not the DB path#2526sbra0902 wants to merge 1 commit into
Conversation
On iOS the SQLCipher salt is kept in the keychain, since the plaintext header needed for WAL background access displaces the in-file salt. It was keyed by the database's absolute path, but iOS relocates the app data container (the path UUID changes on reinstall, update, restore and migration), so the salt is no longer found and the keystore fails to open with "SQL error or missing database". Key the salt by a stable, path-independent id kept in a sidecar file next to the database instead. Pre-existing installs fall back to the legacy path-derived key (never deleted) and copy the salt under the stable key; brand-new keystores store the freshly generated salt directly.
|
Hi @sbra0902 Before we fix anything I'd like to understand the use case. You have a use case where you are moving the core crypto database around? |
No — we're not moving the database. iOS relocates the app's data container, and CoreCrypto keys the SQLCipher salt in the keychain by the keystore's absolute path (keystore_salt_<sha256(abs_path)>, service wire.com). When iOS changes the …/Application//… segment (reinstall, dev redeploy, restore, device transfer, some OS migrations), the DB file migrates with the container and is intact — but the salt is still keyed under the old path, so open fails with MlsException.Other: Error code 1: SQL error or missing database. The app is doing the right thing: it resolves the container path fresh each launch and never persists it. The bug is that a transient absolute path got baked into a persistent keychain key. Same binary, no reinstall (path unchanged) → works; any container relocation → fails. Verified on device: before: …/Application/E6B1DD90-CFF9-43CD-9854-B5F6037B4AED/… This is documented iOS behaviour, and Apple's recommended mitigations are exactly the options on the table: ▎ "the path to … your app's container … can change. There's no way to prevent this. Your app must be able to cope with this … Store a path relative to the root of your container … [or] store an absolute path but be prepared to 'fix' that when the container changes path." This PR takes the first option: key the salt by a stable, container-relative id, with a non-destructive fallback to the old path key so existing installs keep working. If you'd prefer to solve it at the API boundary (caller passes a stable id instead of the absolute path), we're happy to go that way instead. |
|
To be precise, CoreCrypto doesn’t necessarily key the SQLCipher salt by the keystore’s absolute path. It hashes the database-path string supplied by the caller verbatim. That may be either relative or absolute; in fact, the code deliberately avoids SQLite’s canonicalized absolute path. A stable relative path would avoid the container-UUID problem, provided it is resolved consistently across launches. |
You're right — CoreCrypto hashes the caller's string verbatim and deliberately doesn't canonicalize, so a stable string would give a stable salt key. The crux is that the path has two jobs: it's both where the database file lives and the input to the salt key. In open_internal the same string goes to std::fs::exists(path) and Connection::open(path), and only then to the salt keying: let exists = std::fs::exists(path)?; On iOS that couples them. SQLite (and Rust std::fs) resolve a relative filename against the process working directory, not the app container — and an iOS app's working directory isn't its container. So a relative path makes the database resolve outside the sandbox and open/create fails. To make a relative path land in the container you'd have to chdir() the whole process into it on every launch — which still requires resolving the absolute, UUID-bearing container path first, and leans on fragile process-global CWD state that doesn't fit the kalium storage layer. So the string we can actually pass is the absolute container path (NSHomeDirectory()/...), and that's what ends up keying the salt — hence the instability. The fix decouples the two roles: keep the absolute path for file I/O, but key the salt by a stable, container-relative id (this PR), or add a stable-id parameter to the API. Same outcome; the PR just avoids the API change. |
|
Ok, we think this makes sense but we'll discuss internally a bit how we want do it. |
fix(keystore): key the iOS SQLCipher salt by a stable id, not the DB path
Problem
On iOS the keystore (Proteus and MLS) fails to open after the app's data-container path changes,
with:
This happens on reinstalls, updates, restores, migrations, and dev rebuilds/redeploys. The database
file itself is intact and migrates with the container — only the salt lookup fails.
Root cause
To keep iOS from killing backgrounded apps that hold a WAL database, we set
cipher_plaintext_header_size = 32so theSQLite format 3\0header stays readable. That plaintextheader displaces SQLCipher's in-file salt, so the salt is stored externally in the iOS keychain.
It was keyed by the database's absolute path:
keystore_salt_<sha256(absolute_path)>(servicewire.com). iOS does not guarantee a stable container path — the…/Application/<UUID>/…segmentchanges when the container is relocated. New path ⇒ different key ⇒ the salt is no longer found ⇒
SQLCipher derives the wrong key ⇒ open fails.
Fix
Key the salt by a stable, path-independent id instead of the path.
<db>.salt-id). Thesidecar lives in the same container, so it travels with the database and the derived key survives
any relocation.
keystore_salt_v2_<id>.to reading the old
keystore_salt_<sha256(path)>entry, adopt that salt, and copy it under thestable key. The legacy entry is never deleted or overwritten, so an interrupted migration (or a
lost sidecar) can always fall back to it again.
The sidecar is written atomically (temp file + rename); a present-but-corrupt (wrong-length) sidecar
fails loud rather than silently minting a new id on top of a keystore whose salt could then no longer
be located.
Why this is safe for existing installs
copied to the stable key.
Connection::path()) is used, so relativevs. absolute paths still resolve the sidecar and legacy key consistently.
Verification
keystore::connection::ios_salt_id): create/read roundtrip, idempotency,id + keychain key survive a container move (the exact bug scenario), corrupt-sidecar error, no
leftover temp files, legacy key matches the historical scheme, v2 key format.
aarch64-apple-ios.(
healKeystoreSaltAfterContainerMove) empirically confirms both the diagnosis and the mechanism —it copies the salt from the old-path key to the current-path key on a home-dir change, which
resolves the same failure. This fix moves the fix down to core-crypto so every consumer is covered
and such app-layer workarounds can be retired.
Files
keystore/src/connection/ios_salt_id.rs— new: sidecar-id + keychain-key derivation (+ unit tests).keystore/src/connection/ios_wal_compat.rs— key by the stable id with the non-destructive legacyfallback.
keystore/src/connection/mod.rs— register the module; note on using the caller path.